
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
批量将课程市场（ucoursemarket.aspx）里的所有课程加入到账号中（“学习本课”）
参考 new_crawler_api_all_course.py 的登录、请求头、双重编码与 HTML 解析方式。
步骤：
1) 登录（pa.login -> requests.Session）并种入 les 课堂必需的 cookies
2) 通过 POST: LoadUCourseMarket 分页拉取课程（PageSize=100），解析 Html 中的 AddToMyCourse(...) 参数
3) 对每门课程调用 POST: AddToMyCourse 完成加入，打印“第N门/总数、课程名、状态”

说明：
- 默认每页 100 条；自动识别“共X条/Y页”后逐页遍历
- 采用与 new_crawler_api_all_course.py 相同的 headers 与 cookie 处理
- 如服务器对 ParamsPageInfo.StrCondition 有强校验，代码内提供了一个示例常量作兜底；若返回为空，可根据需要从浏览器抓包替换
"""

import os
import re
import json
import logging
import urllib.parse as urlp
from typing import Any, Dict, List, Tuple

import requests
from bs4 import BeautifulSoup

from pa import login  # type: ignore

logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')

BASE = "https://les.znjiyi.com"
HANDLE_URL = f"{BASE}/a/Handle/ExamLib_Process.aspx"
MARKET_URL = f"{BASE}/a/ucoursemarket.aspx"
UCOURSE_URL = f"{BASE}/a/ucourse.aspx"

# 某些环境下 StrCondition 可能为必填；此处保留一个参考常量，必要时可替换
# 也可通过环境变量 LERS_MARKET_STR_CONDITION 覆盖
DEFAULT_STR_CONDITION = os.getenv(
    "LERS_MARKET_STR_CONDITION",
    "",
)


def enc_double(v: Any) -> str:
    """两次 URL 编码的 JSON 字符串（与站点接口一致）"""
    return urlp.quote(urlp.quote(json.dumps(v, separators=(",", ":"), ensure_ascii=False), safe=""), safe="")

import time


class AddAllCourse:
    def __init__(self) -> None:
        self.session: requests.Session | None = None
        self.total_courses = 0
        self.total_pages = 1
        self.page_size = 100

    # ---------- headers & cookies ----------
    def _headers(self, referer: str, accept: str = 'text/html, */*; q=0.01') -> Dict[str, str]:
        return {
            'accept': accept,
            'accept-language': 'zh-CN,zh;q=0.9',
            'content-type': 'application/x-www-form-urlencoded; charset=UTF-8',
            'origin': BASE,
            'priority': 'u=1, i',
            'referer': referer,
            'sec-ch-ua': '"Not)A;Brand";v="8", "Chromium";v="138", "Google Chrome";v="138"',
            'sec-ch-ua-mobile': '?0',
            'sec-ch-ua-platform': '"macOS"',
            'sec-fetch-dest': 'empty',
            'sec-fetch-mode': 'cors',
            'sec-fetch-site': 'same-origin',
            'user-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36',
            'x-requested-with': 'XMLHttpRequest',
        }

    def _seed_cookies(self) -> None:
        assert self.session is not None
        # 访问市场页，建立 les 域 cookie
        try:
            self.session.get(MARKET_URL, timeout=60, verify=False)
        except Exception:
            pass
        # 确保课堂上下文 cookie
        cj = self.session.cookies
        cj.set('last_classroom_enabbr', 'les', domain='les.znjiyi.com', path='/')
        # 中文课堂名需百分号编码
        cj.set('last_classroom_name', urlp.quote('乐尔思', safe=''), domain='les.znjiyi.com', path='/')
        cj.set('HasGetTopMenu', '0', domain='.znjiyi.com', path='/')
        cj.set('HasGetSliderMenu', '0', domain='.znjiyi.com', path='/')
        # 调用一次 LoadMenuTop 与页面行为对齐
        common = json.dumps([{
            'IsMobile': False,
            'IsTablet': False,
            'ScreenWidth': 1512,
            'ScreenHeight': 701,
            'WindowScreenHeight': 701,
            'IsIOS': False,
            'ReturnUrl': MARKET_URL,
            'CurrentUrl': MARKET_URL,
            'IsInWeixin': False,
            'IP': '',
            'Location': '{"region":"","city":"","country":""}'
        }], separators=(',', ':'), ensure_ascii=False)
        data = {'CommonJsonPara': common, 'processFlag': 'LoadMenuTop'}
        try:
            self.session.post(HANDLE_URL, headers=self._headers(MARKET_URL), data=data, timeout=60, verify=False)
        except Exception:
            pass
        try:
            self.session.get(MARKET_URL, timeout=60, verify=False)
        except Exception:
            pass
        # 也访问“我的课程”页，确保该域下 cookie 完整
        try:
            self.session.get(UCOURSE_URL, timeout=60, verify=False)
        except Exception:
            pass

    # ---------- 拉取市场课程（分页） ----------
    def _build_market_post_data(self, current_page: int, page_size: int) -> bytes:
        params_fore_obj = [{
            "CourseName": "",
            "CourseGradeKind": "1",
            "StudyLevelSecond": "0_0_0",
            "StudyLevel": "0_0",
            "BuyUserID": "",
            "PromoteUserID": "",
            "BuyUserName": "",
            "MarketType": "1",
            "CourseClassID": 1,
        }]
        common_json_obj = [{
            "IsMobile": False,
            "IsTablet": False,
            "ScreenWidth": 1512,
            "ScreenHeight": 323,
            "WindowScreenHeight": 323,
            "IsIOS": False,
            "ReturnUrl": MARKET_URL,
            "CurrentUrl": MARKET_URL,
            "IsInWeixin": False,
            "IP": "",
            "Location": '{"region":"","city":"","country":""}'
        }]
        page_info_obj = [{
            "IFlag": 0,
            "CurrentPage": current_page,
            "PageSize": str(page_size),
            "OrderByDefault": "HotSaleSet desc, HotSale desc",
            # 若该字段为必填，可通过环境变量覆盖；否则尝试留空
            "StrCondition": DEFAULT_STR_CONDITION or "",
            "TotalCount": -1,
            "ShowInDiv": "datacontent",
            "ShowInDivPaging": "",
            "IsPreLoad": 0,
            "ParamsForeJs": "IParamForeUCourseMarket",
            "LoadHtmlSuccessJs": "ILoadHtmlSuccessUCourseMarket",
            "IsAppendHtml": False,
            "PageJsCommon": "CommonLoadHtml"
        }]
        payload = (
            'ParamsBack=&'
            f'ParamsFore={enc_double(params_fore_obj)}&'
            'ParamOrderByList=&'
            'ParamsOther=&'
            f'CommonJsonPara={urlp.quote(json.dumps(common_json_obj, separators=(",", ":"), ensure_ascii=False), safe="")}&'
            f'ParamsPageInfo={enc_double(page_info_obj)}&'
            'PageJs=LoadUCourseMarket&'
            'processFlag=LoadUCourseMarket'
        )
        return payload.encode('utf-8')
    # ---------- 拉取“我的课程”名称集合（用于去重跳过） ----------
    def _build_my_post_data(self, current_page: int, page_size: int, course_name: str | None = None) -> bytes:
        params_fore_obj = [{
            "IsFirstLoad": current_page == 1,
            "TabIndex": "1",
            "TabIdSuffix": "navtab",
            "CourseName": course_name or "",
            "CourseGradeKind": "undefined",
            "GroupClassID": "",
            "IsDIY": False
        }]
        common_json_obj = [{
            "IsMobile": False,
            "IsTablet": False,
            "ScreenWidth": 1512,
            "ScreenHeight": 322,
            "WindowScreenHeight": 322,
            "IsIOS": False,
            "ReturnUrl": UCOURSE_URL,
            "CurrentUrl": UCOURSE_URL,
            "IsInWeixin": False,
            "IP": "",
            "Location": '{"region":"","city":"","country":""}'
        }]
        page_info_obj = [{
            "IFlag": 0,
            "CurrentPage": current_page,
            "PageSize": page_size,
            "OrderByDefault": "LastUseTime desc, UCourseLastUpTime desc",
            "StrCondition": "",
            "TotalCount": -1,
            "ShowInDiv": "datacontent",
            "ShowInDivPaging": "",
            "IsPreLoad": 0,
            "IsAppendHtml": False,
            "PageJsCommon": "NenverLoadHtml"
        }]
        payload = (
            'ParamsBack=&'
            f'ParamsFore={enc_double(params_fore_obj)}&'
            'ParamOrderByList=&'
            'ParamsOther=&'
            f'CommonJsonPara={urlp.quote(json.dumps(common_json_obj, separators=(",", ":"), ensure_ascii=False), safe="")}&'
            f'ParamsPageInfo={enc_double(page_info_obj)}&'
            'PageJs=LoadUCourse&'
            'processFlag=LoadUCourse'
        )
        return payload.encode('utf-8')

    def _maybe_relogin(self) -> None:
        """如会话失效则重新登录并重新种 cookie"""
        try:
            r = self.session.get(UCOURSE_URL, timeout=60, verify=False)
            if 'Login.aspx' in (r.url or '') or ('name="form1"' in r.text and 'Login.aspx' in r.text):
                logging.warning('⚠️ 会话可能失效，准备重新登录并初始化 cookie')
                self.session = login()
                self._seed_cookies()
        except Exception:
            try:
                self.session = login()
                self._seed_cookies()
            except Exception:
                pass

    def fetch_my_course_names(self, page_size: int = 100) -> set[str]:
        assert self.session is not None
        names: set[str] = set()
        # 先请求第 1 页拿分页
        data = self._build_my_post_data(1, page_size)
        res = self.session.post(HANDLE_URL, headers=self._headers(UCOURSE_URL), data=data, timeout=60, verify=False)
        res.encoding = 'utf-8'
        parsed = self._double_json_loads(res.text)
        html = parsed.get('Html') or parsed.get('html') or ''
        soup = BeautifulSoup(html, 'html.parser')
        for card in soup.select('#datacontent div.card') or soup.select('div.card'):
            header = card.select_one('div.card-header.p-2 > div.card-title') or card.select_one('.card-title')
            if header:
                nm = header.get_text(strip=True)
                if nm:
                    names.add(nm)
        # 分页数
        total_pages = 1
        m = re.search(r"/\s*(\d+)\s*页", html)
        if m:
            try:
                total_pages = int(m.group(1))
            except Exception:
                total_pages = 1
        # 后续页
        for p in range(2, total_pages + 1):
            data = self._build_my_post_data(p, page_size)
            res = self.session.post(HANDLE_URL, headers=self._headers(UCOURSE_URL), data=data, timeout=60, verify=False)
            res.encoding = 'utf-8'
            parsed = self._double_json_loads(res.text)
            html = parsed.get('Html') or parsed.get('html') or ''
            soup = BeautifulSoup(html, 'html.parser')
            for card in soup.select('#datacontent div.card') or soup.select('div.card'):
                header = card.select_one('div.card-header.p-2 > div.card-title') or card.select_one('.card-title')
                if header:
                    nm = header.get_text(strip=True)
                    if nm:
                        names.add(nm)
        logging.info(f"🧾 已拥有课程：{len(names)} 门（来自我的课程列表）")
        return names

    def _double_json_loads(self, text: str) -> Dict[str, Any]:
        try:
            return json.loads(json.loads(json.dumps(text)))
        except Exception:
            try:
                return json.loads(text)
            except Exception:
                return {}

    def _parse_total_from_html(self, html: str) -> Tuple[int, int]:
        total, pages = 0, 1
        m = re.search(r"共\s*(\d+)\s*条\s*/\s*(\d+)\s*页", html)
        if m:
            total, pages = int(m.group(1)), int(m.group(2))
        else:
            soup = BeautifulSoup(html, 'html.parser')
            info = soup.select_one('.div-center-center')
            if info:
                txt = info.get_text(' ', strip=True)
                m2 = re.search(r"共\s*(\d+)\s*条\s*/\s*(\d+)\s*页", txt)
                if m2:
                    total, pages = int(m2.group(1)), int(m2.group(2))
        return total, pages

    def _extract_add_calls(self, html: str) -> List[Dict[str, str]]:
        """从 Html 解析 AddToMyCourse 参数与课程名称"""
        out: List[Dict[str, str]] = []
        if not html:
            return out
        soup = BeautifulSoup(html, 'html.parser')
        # 容器卡片
        cards = soup.select('#datacontent div.card') or soup.select('div.card')
        for card in cards:
            # 仅处理“学习本课程”状态；若 footer 含“正在体验本课程”则跳过
            footer = card.select_one('.card-footer')
            if footer and '正在体验本课程' in footer.get_text(strip=True):
                continue
            # 课程名（兼容不同结构）
            name = ''
            header = card.select_one('div.card-header.p-2 > div.card-title') or card.select_one('.card-title')
            if header:
                name = header.get_text(strip=True)
            # 按钮/链接上带 onclick
            btn = card.select_one("button[onclick*='AddToMyCourse'], a[onclick*='AddToMyCourse'], [onclick*='AddToMyCourse']")
            if not btn:
                # 仍兜底扫描整个 card 文本，但要求 footer 不含“正在体验本课程”
                onclicks = re.findall(r"AddToMyCourse\([^)]*\)", str(card))
                if not onclicks:
                    continue
                onclick = onclicks[0]
            else:
                onclick = btn.get('onclick', '')
            # 解析参数：ID, IsAdd, CourseClassID, WordBaseKind, UID
            qs = re.findall(r"'([^']*)'", onclick)
            if len(qs) >= 5:
                ID, IsAdd, CourseClassID, WordBaseKind, UID = qs[:5]
            else:
                # 兜底：按逗号拆分
                m = re.search(r"AddToMyCourse\(([^)]*)\)", onclick)
                if not m:
                    continue
                raw = [x.strip().strip("'\"") for x in m.group(1).split(',')]
                if len(raw) < 5:
                    continue
                ID, IsAdd, CourseClassID, WordBaseKind, UID = raw[:5]
            out.append({
                'name': name or ID,
                'ID': ID,
                'IsAdd': IsAdd,
                'CourseClassID': CourseClassID,
                'WordBaseKind': WordBaseKind,
                'UID': UID,
            })
        return out

    def list_one_page(self, current_page: int, page_size: int = 100) -> Tuple[List[Dict[str, str]], int, int]:
        assert self.session is not None
        data = self._build_market_post_data(current_page, page_size)
        res = self.session.post(HANDLE_URL, headers=self._headers(MARKET_URL), data=data, timeout=60, verify=False)
        res.encoding = 'utf-8'
        parsed = self._double_json_loads(res.text)
        html = parsed.get('Html') or parsed.get('html') or ''
        if not (self.total_courses and self.total_pages):
            t, p = self._parse_total_from_html(html)
            if t:
                self.total_courses, self.total_pages = t, p
        items = self._extract_add_calls(html)
        logging.info(f"📄 第 {current_page}/{self.total_pages or 1} 页，解析到 {len(items)} 个课程 AddToMyCourse")
        return items, self.total_courses, self.total_pages
    def _normalize_message(self, text: str) -> tuple[bool, str]:
        """解析 AddToMyCourse 返回，兼容 JSON/HTML/文本，返回(是否成功, 消息)"""
        t = (text or '').strip()
        if not t:
            return False, ''
        # HTML 表单页：ASP.NET 回退（视作失败，但返回短消息）
        if t.lstrip().startswith('<!DOCTYPE') or '<html' in t.lower():
            return False, 'HTML返回（可能需补充Header/Cookie/Referer）'
        # JSON
        try:
            j = json.loads(t)
            # 字段名兼容：result/IsSuccess/success/Success
            is_ok = bool(j.get('result') is True or j.get('IsSuccess') or j.get('success') or j.get('Success'))
            msg = j.get('message') or j.get('Message') or j.get('msg') or j.get('Msg') or t
            return is_ok, msg
        except Exception:
            pass
        # 文本：包含“添加成功/恢复成功/成功”等
        low = t.lower()
        if ('success' in low) or ('成功' in t):
            return True, t
        return False, t


    # ---------- 加入课程 ----------
    def add_course(self, item: Dict[str, str]) -> Tuple[bool, str]:
        assert self.session is not None
        # 支持 DRY-RUN（环境变量 LERS_ADD_DRY_RUN 设置为 1/true 即可只验证不提交）
        if os.getenv('LERS_ADD_DRY_RUN', '').lower() in ('1', 'true', 'yes', 'y'):
            return True, 'dry-run: 未实际提交 AddToMyCourse'
        # 构造与浏览器一致的 x-www-form-urlencoded（字符串再编码）
        data_string = (
            'ID=' + urlp.quote(item['ID'], safe='') +
            '&IsAdd=' + str(item['IsAdd']).lower() +
            '&CourseClassID=' + str(item['CourseClassID']) +
            '&WordBaseKind=' + str(item['WordBaseKind']) +
            '&UID=' + urlp.quote(item['UID'], safe='') +
            '&processFlag=AddToMyCourse'
        )
        headers = self._headers(MARKET_URL, accept='application/json, text/javascript, */*; q=0.01')
        try:
            r = self.session.post(HANDLE_URL, headers=headers, data=data_string.encode('utf-8'), timeout=60, verify=False)
            r.encoding = 'utf-8'
            text = r.text.strip()
            ok, msg = self._normalize_message(text)
            return ok, msg
        except Exception as e:
            return False, f"请求失败: {e}"

    # ---------- 主流程 ----------
    def run(self, page_size: int = 100) -> None:
        # 1) 登录 + 种 cookie
        logging.info('登录中...')
        self.session = login()
        self._seed_cookies()

        # 2) 第一次请求以拿到总数/总页数
        self.page_size = page_size
        current_page = 1
        all_items: List[Dict[str, str]] = []
        # 节流：避免一次性过快提交，防止后端限流/状态尚未可见
        time.sleep(float(os.getenv('LERS_ADD_EACH_DELAY', '0.4')))

        items, total, pages = self.list_one_page(current_page, page_size)
        all_items.extend(items)
        total = self.total_courses or total
        pages = self.total_pages or pages
        logging.info(f"📚 市场共有 {total or len(all_items)} 门课程，共 {pages} 页（每页 {page_size} 条）")

        # 后续页
        for p in range(2, (pages or 1) + 1):
            items, _, _ = self.list_one_page(p, page_size)
            all_items.extend(items)

        if total and len(all_items) != total:
            logging.warning(f"⚠️ 课程总数与解析条目不一致：页面{total}，解析{len(all_items)}")

        # 2.5) 载入“我的课程”用来跳过已加入
        my_names = self.fetch_my_course_names(page_size=100)

        # 3) 逐个加入（跳过已拥有者）
        added_ok = 0
        skipped = 0
        for idx, it in enumerate(all_items, start=1):
            name = it.get('name') or it.get('ID')
            if name in my_names:
                logging.info(f"第 {idx}/{len(all_items)} 门｜{name}｜⏭️ 已在我的课程中，跳过")
                skipped += 1
                continue
            ok, msg = self.add_course(it)
            # 防会话过期：每次循环前探测并必要时重新登录
            self._maybe_relogin()

            if ok:
                logging.info(f"第 {idx}/{len(all_items)} 门｜{name}｜✅ 成功｜{msg}")
                added_ok += 1
            else:
                # 如果返回提示已经添加/已在课程中，视为跳过
                lower_msg = msg or ''
                if any(key in lower_msg for key in ['已在', '已添加', '已加入', '已购买', '已经', '已选']):
                    logging.info(f"第 {idx}/{len(all_items)} 门｜{name}｜⏭️ 已拥有（接口提示）｜{msg}")
                    skipped += 1
                else:
                    logging.info(f"第 {idx}/{len(all_items)} 门｜{name}｜❌ 失败｜{msg}")
                # 添加后刷新“我的课程”并等待，再继续下一个（无论成功/失败都节流刷新）
                time.sleep(float(os.getenv('LERS_AFTER_ADD_SLEEP', '0.8')))
                try:
                    self.session.get(UCOURSE_URL, timeout=60, verify=False)
                except Exception:
                    pass
                time.sleep(float(os.getenv('LERS_RELOAD_MY_SLEEP', '0.8')))
                try:
                    my_names = self.fetch_my_course_names(page_size=100)
                except Exception:
                    pass
                time.sleep(float(os.getenv('LERS_ADD_EACH_DELAY', '0.4')))
        logging.info(f"🎉 完成：共 {len(all_items)} 门，成功 {added_ok} 门，跳过 {skipped} 门，失败 {len(all_items) - added_ok - skipped} 门")


def main():
    AddAllCourse().run(page_size=100)


if __name__ == '__main__':
    main()
